iT邦幫忙

2023 iThome 鐵人賽

DAY 14
0
Modern Web

深入淺出,完整認識 Next.js 13 !系列 第 14

Day 14 - local storage is not defined 與 Text content does not match server-rendered HTML 錯誤

  • 分享至 

  • xImage
  •  

還記得 Day 11 我們有提到,假如要使用 client-only 的 features 時 (ex: window, local storage 等 ),就必須使用 Client Components 嗎?

但在 Server Components 和 Client Components 的基礎篇最後,想跟大家分享一個在 Client Components 使用 client-only features 時要注意的地方。


情境是這樣:

為了讓使用者在命名群組名稱時,不小心按到重新整理,還可以記錄之前輸入的內容,所以希望:

  • 有個命名群組名稱的 input,當使用者輸入文字時,文字會被存進 local storage 的 group_name_input 這個 item 中。
  • 進入命名頁時,假設 local storage 有 group_name_input 這個 item,則輸入框預設值為 group_name_input 的值;反之 input value 為 未命名群組

以這個需求,我寫了一個簡單的 solution,邏輯大概是:

  • 建一個 userInput 的 state 來控制 input 的值。
  • 假設 local storage 中 有 group_name_input 這個 item,則 userInput 的初始值為 group_name_input 的值;若為 null 則 userInput 的初始值為 "未命名群組" 。
  • 當使用者輸入文字時,會 setState 使用者輸入的內容並將內容存進 local storage 的 group_name_input 中。
  • 因為會使用到 client-only 的 state 和 local storage API,我們在檔案最上方標記 ‘use client’ 建立 Client Component 的 boundary。

程式碼如下:

/* app/group/edit/page.tsx */
'use client';

import { ChangeEvent, useState } from 'react';

export default function Page() {
  const localStorage_input =
    localStorage.getItem('group_name_input') ?? '未命名群組';
  const [userInput, setUserInput] = useState(localStorage_input);

  const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
    setUserInput(e.target.value);
    localStorage.setItem('group_name_input', e.target.value);
  };

  return (
    <input
      value={userInput}
      onChange={handleChange}
      className='w-[200px] h-[50px] border border-solid border-black'
    />
  );
}

結果發現 run dev 的 terminal 跳出了 ReferenceError: localStorage is not defined 的報錯。這時你可能會想,奇怪,明明都標記 'use client' 了,為什麼還會有這個 error?

還記得我們在 Day 10 有提到,Client Component 首次渲染也是在 server-side 嗎?

To optimize the initial page load, Next.js will use React's APIs to render a static HTML preview on the server for both Client and Server Components. This means, when the user first visits your application, they will see the content of the page immediately, without having to wait for the client to download, parse, and execute the Client Component JavaScript bundle.

Next.js - How are Client Components Rendered

所以 Client Components 在 server-side 首次渲染時,server 無法使用 local storage API,就跳出了 ReferenceError: localStorage is not defined 的報錯。

那該怎麼解決這個問題呢?這時候我想到兩個路線可以嘗試:

  1. 告訴 Next.js 只在 client-side 呼叫 local storage API
  2. 讓 component 改為 CSR

在 client-side 才呼叫 local storage API

要怎麼實作呢?我在 Stack Overflow 看到兩個常見回答:

  1. typeof window 來判斷目前在 client-side 還是 server-side,再決定要不要呼叫 local storage API
  2. useEffect 確保 component 渲染完才會呼叫 local storage API

先來試試看第一種作法:

  1. 寫一個 getDefaultInput(),假如 typeof window !== undefined,表示在 client-side 被呼叫,才去 call local storage API,並 return group_name_input 的值;不然就直接 return 未命名群組
const getDefaultInput = () => {
  if (typeof window !== 'undefined') {
    return localStorage.getItem('group_name_input') ?? '未命名群組';
  }
  return '未命名群組';
};
  1. userInput 的初始值設為 getDefaultInput() return 的值:
export default function Name() {
  const [userInput, setUserInput] = useState(getDefaultInput());

  const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
    setUserInput(e.target.value);
    localStorage.setItem('group_name_input', e.target.value);
  };

  return (
    <input
      value={userInput}
      onChange={handleChange}
      className='w-[200px] h-[50px] border border-solid border-black'
    />
  );
}

改完後重新整理看看,的確報錯消失了,網頁也可正常運作。


補充:至於為什麼出現 local storage is not defined 後,dev mode 下網頁還是可以正常運作,這部分我還沒找到答案,問 ChatGPT 它是說在開發環境下,Next 執行 server-side rendering 假如遇到問題,會嘗試透過轉成 client-side rendering 來修復問題,所以就算 server 報錯,測試環境下網頁還是可以正常運作。但畢竟 ChatGPT 說的話只能當參考,假如有大大知道原因或有找到相關討論,也歡迎跟我分享!

正式環境就無法正常 loading 頁面了。假如用 Vercel 提供的部署服務,在 run build 時就會發生錯誤。
https://ithelp.ithome.com.tw/upload/images/20230914/201618530zzv4f1orG.png


回到正題,但這個做法可能會有個問題:

我們來加一個按鈕,讓它的文字是 getDefaultInput() return 的值,且點擊它時會 console.log("click!”)

'use client';

export default function Page() {
...
  return (
    <>
    ...
      <button onClick={() => console.log('click')}>{getDefaultInput()}</button>
    </>
  );
}

寫完後,在 local storage 中有 group_name_input 時 ( 以 'abcde' 為例 ) 重新整理,這時瀏覽器跳出:

Warning: Text content did not match. Server: "未命名群組" Client: "abcde",以及幾個 hydration 相關的錯誤。

發生了什麼事呢?在介紹 Pre-Rendering 時我們有提到,server 會先以 state 的初始值渲染靜態的 html 文件,並附帶一份如何加入動態效果的 JSON 指引,回給瀏覽器。瀏覽器再依據這份 html 和 JSON 檔在對應的 DOM elements 上加入動態效果。

上述例子中,server 在渲染 initial HTML 時,因為 typeof window === 'undefined',所以會讓 button 的文字是 未命名群組;但到瀏覽器時,因為 typeof window !== 'undefined',且有 group_name_input 這個 item,所以 userInput 預設值會是 group_name_input 的值 ( "abcde” )。

當瀏覽器在做 hydration 時,就會發生 server 渲染的 React tree ( button 的文字是「未命名群組」),和瀏覽器透過 JavaScript 組成的 React tree ( button 文字是「abcde」) 不一樣的問題。

除了 typeof window 條件式可能會造成上述問題外,還有幾個情境容易引發同樣問題:

  1. 不合規定地 HTML 巢狀結構 ( ex: <div><p> 包在 <p><a>包在 <a> 中 )
  2. 使用會操作 DOM 的瀏覽器 extensions,可以參考這個 issue
  3. CSS-in-JS 設定錯誤
  4. CDN 含有會修改 html response 的功能 ( ex: Cloudflare Auto Minify )

這邊就不細談每個案例,我自己除了第一和用 typeof window 條件式有踩過雷外,其他情況也還沒碰過。假如有興趣的朋友可以參考官方文件

所以假如要限制一個 function 只有在 client-side 才執行,官方比較建議的會是第二種作法,把邏輯寫在 useEffect 裡,確保這個 function 會在 component 在瀏覽器渲染完後才會執行:

import { useState, useEffect } from 'react'
 
export default function App() {
  const [isClient, setIsClient] = useState(false)
 
  useEffect(() => {
    setIsClient(true)
  }, [])
 
  return <h1>{isClient ? 'This is never prerendered' : 'Prerendered'}</h1>
}

將 component 轉為 CSR

另一個方法是,透過 next/dynamic 來讓特定 component 不要 pre-rendered。但 next/dynamic 一般是想做 lazy loading 時使用,一般狀況上使用 useEffect 即可。next/dynamic 會在 Day 27 談 script 載入優化時會再進一步分享!

import dynamic from 'next/dynamic'
 
const NoSSR = dynamic(() => import('../components/no-ssr'), { ssr: false })
 
export default function Page() {
  return (
    <div>
      <NoSSR />
    </div>
  )
}

強制關閉警告

假如真的無法避免 server 和 client 渲染內容不同的狀況,比方說當我就是想在頁面直接顯示 timestamp:

'use client';

export default function page() {
  const time = new Date().toString();
  return <div>{time}</div>;
}

因為 server-side rendering 的時間跟後續 client-side 後續 hydration 的時間一定不同,就會跳出警告:
https://ithelp.ithome.com.tw/upload/images/20230914/20161853HptYv3dgZd.png

假如你想強制關閉這個警告,可以在 JSX tag 上加入 suppressHydrationWarning

'use client';

export default function page() {
  const time = new Date().toString();
  return <div suppressHydrationWarning>{time}</div>;
}

以上就是在 Client Components 使用 client-only features 的一個小提醒,希望對大家有幫助!假如有什麼其他使用上的注意事項,也歡迎大家分享!

謝謝大家耐心的閱讀,我們明天見!


上一篇
Day 13 - 怎麼限定模組使用環境? Server Components 使用第三方套件要注意什麼?
下一篇
Day 15 - 提升 Server-Side Rendering 的使用者體驗:Streaming、Suspense 與 loading.tsx
系列文
深入淺出,完整認識 Next.js 13 !30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言